feat/positioning-redesign -> staging#128
Conversation
Adds `isContributorWeightAcceptable` — empty (the field is optional) or a finite non-negative number. Uses `Number(v)` so a trailing "10abc" is rejected; parseFloat would silently truncate to 10. Decimals are explicitly allowed since the lexicon stores weights as free-form strings and a value like "0.25" or "1.5" is fine. UX: invalid weight inputs paint the same red border as invalid identity inputs, expose `aria-invalid`, and surface an inline "Weight must be a number (decimals are fine, e.g. 1.5)." error below the row. canSubmit and handleSubmit both gate on every weight being valid. Input gets `inputMode="decimal"` so mobile keyboards show the numeric pad. The cert detail page's `buildWeightPercents` already silently ignored non-numeric weights when computing the % column; this patch makes that invariant visible at creation time so authors can't save weights the renderer would skip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The thin blue box that appeared around the first paragraph when
clicking into the editor came from ProseMirror's default rule in
prosemirror-view/style/prosemirror.css:
.ProseMirror-selectednode { outline: 2px solid #8cf; }
Clicking into an empty editor can put it in a NodeSelection on the
first paragraph, which then renders with the default outline. We
have explicit selected-state styling for images
(`.leaflet-doc__image--selected`) and embeds
(`.leaflet-doc__embed--selected`) via React NodeViews, so the
default outline is redundant for the blocks we actually want to
highlight. Scoping the override to `.leaflet-editor__surface` keeps
the rule out of the read-only renderer and other ProseMirror
surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…kill
Reinstates `.leaflet-editor__content:focus-within` so the editor
matches the title + short-description inputs on focus (idle
border becomes primary, plus a 2px overlay-weak ring). Was
removed in an earlier pass that over-corrected to fix the first-
line box; this brings the editor visually in line with its peers
again.
Doubles down on the first-line box suppression:
- `.ProseMirror-selectednode` override keeps `!important` so a
later stylesheet load order can't override it on accident.
prosemirror-view ships its style/prosemirror.css with
`sideEffects: [...]`, so the default outline IS in the bundle.
- Adds `> *:focus` / `> *:focus-visible { outline: none }` on
direct children of the surface as a belt-and-suspenders catch
for any browser-default focus ring that some WebKit builds
paint on individual blocks of a contenteditable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…down, nuclear first-line outline kill Location modal: - maxWidth bumped 620 → 1100 so the "Add location" modal matches the "view location" modal that the cert detail page opens. The author flow and the reader flow now share a frame size. - Address-typing input stretches to fill the modal width via the existing `create-cert__field--full` modifier (was sitting at the browser's default ~150px input width). - "Existing URI" tab → "My locations" with a dropdown. On mount the dialog listRecords the signed-in user's `app.certified.location` collection (limit 100, sorted by name). Selecting a row + Add pushes that strongRef directly — no URI-paste or extra round-trip needed. Empty state and load-error messages cover the edge cases. Leaflet editor first-line border, take three: - Cast a much wider net than the previous targeted overrides. Every descendant of `.leaflet-editor__surface` gets `outline: 0 !important; outline-style: none !important` across every state (idle / :focus / :focus-visible / selectednode). Direct-child blocks also get `box-shadow: none !important` so no inner ring can paint either. - Selection cues for the blocks we actually want highlighted (images + iframe embeds) ride on their own `.leaflet-doc__image--selected` / `.leaflet-doc__embed--selected` classes painted by the React NodeViews, so wiping the default outline doesn't drop those affordances. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Native placeholders on `.cert-detail__title-input`, `.cert-detail__short-desc-input`, and `.cert-detail__meta-input` now use `var(--fg-muted)` — matching the Leaflet editor's own placeholder (which paints via a `::before` pseudo at the same token). The browser default (~rgba(0,0,0,0.42)) read as a cooler gray than the editor placeholder, so the description field looked like a different typography family. `opacity: 1` on the rule neutralises Firefox's default 0.54 multiplier. - Date inputs (Time period) don't expose `::placeholder`; their "mm/dd/yyyy" format hint is painted via `::-webkit-datetime-edit`. Coloring that pseudo `--fg-muted` brings the Time-period placeholder into the same rhythm. Filled values inherit the muted color, which reads naturally in the aside meta column. - Default the Rights dropdown to "Public Display of Contributions" once the curated list loads. Match by exact name; the dropdown still functions if the publisher renames the record (the user just has to pick manually). Only sets a default when no rights selection already exists, so re-renders don't clobber a manual pick. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r modal - My locations is now the first + default tab (was "New"); most authors are picking from a location they already published, not minting a fresh record on every cert. - Each MyLocation now carries a parsed `coords` field (LatLng | null) derived from the record's `location` field via `parseLocationShape`. Polygons + smallBlob variants resolve to null and just won't pin; the strongRef is still attached on Add. - The Existing tab gains a Leaflet map preview that drops a pin on the selected location's coords. The hint line under the map reads either the pinned coordinates, "no pinnable coordinates" for non-point variants, or a "Pick a location above" prompt when nothing is selected yet. - Map height grows from a fixed 280px to a viewport-aware `Math.min(560, Math.max(320, innerHeight * 0.6))`. The taller map drives a taller modal — bringing the dialog visually in line with the cert detail "view location" modal that uses the same calc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…height Leaflet editor: - Adds `lastProducedJsonRef` — the JSON-stringified LinearDocument the editor most recently produced via its onUpdate. The controlled-state sync effect now compares the incoming `value` prop against this stamp and bails early when they match, recognising the parent's setState echo of our own update. - The previous "compare against getJSON()" guard misfired when `tiptapToLinearDocument` → `linearDocumentToTipTap` wasn't byte-identical (e.g. empty paragraphs got `content: []` injected on the way back). That triggered `setContent` on every keystroke, which calls `tr.replaceWith` under the hood and resets the selection — most visibly when pressing Enter inside a list, where the cursor would jump over empty bullet items and land at the end of the next heading. - Comparing the parent-passed LinearDocument against the one we produced (LinearDocument-vs-LinearDocument, not TipTap-vs-TipTap) sidesteps that whole class of round-trip mismatch. Location modal: - `mapHeight` now uses the exact calc the cert-detail "view location" modal uses — `Math.min(720, innerHeight * 0.7)` — so the add-location and view-location dialogs have identical body heights, not just identical widths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…itle Location picker: - Selecting from the My-locations dropdown now jumps + zooms in to that record. Leaflet only honours `MapContainer` initial center/zoom on mount, and our shared MapDataEffect intentionally skips re-centering when a single pin moves (avoids a lurch on every click-to-pin in the New tab). Re-mounting the `<Map>` via a `key` prop that includes the selected URI is the lightest way to get jump-and-zoom only for the Existing-tab flow. Pin zoom is 13 in Existing mode, 6 in New mode. - New tab now leads with a hint line: "Type a place to search, or click anywhere on the map to drop a pin." Mirrors the affordance copy used elsewhere in the app for combobox + map pickers. - If the user picks coords in the New tab that already match a location in My locations (within 11m / 4 decimal places), the submit short-circuits to the existing strongRef instead of minting a duplicate. A `create-cert__loc-reused` banner reads "Already in My locations — using your existing record: X" for ~1.4s before the dialog closes. Cert form: - `.create-cert .cert-detail__title-input` drops from 1.875rem to 1.375rem. The full-size detail-page edit treatment is kept intact; only the create form scopes down so the title doesn't visually overpower the rest of the inputs sitting next to it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
One step past the full-world view. The empty-state map opened so zoomed-out that the user couldn't tell which continent they were looking at; zoom 2 puts the planet at a more readable scale without forcing a region commitment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The New tab now has two text fields with distinct roles:
- "Search a city or address" sits above the map and drives the
Nominatim typeahead. Picking a suggestion (or clicking the map)
resolves into a pin and clears the search box so the user can
see the search has settled.
- "Name" sits below the map and is the value the
`app.certified.location` record is saved with. It auto-fills
from the picked/reverse-geocoded display name but the user can
freely overwrite it with something more specific ("Main office"
instead of "123 Main St, San Francisco, CA, United States").
Editing the Name field does NOT re-fire a search, which fixes
the previous coupling where any rename pulled the user back
into the search dropdown.
The lastSourceRef tracking that gated the search effect against
map-originated changes is gone — splitting the inputs makes it
unnecessary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New `create-cert__loc-fields` grid wrapper places the Search and Name inputs as two equal columns above the map (collapses to a stack below 560px). The Name field's standalone label + section below the map is gone — the inline placeholder "Name (you can rename it)" carries the intent in less vertical space. Combobox gets `min-width: 0` so the grid column doesn't blow out when the suggestion list contains long display names. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Uses the existing `usePageTitle` hook (lib/navbar-context.tsx) so /create shows "New cert" in the navbar title slot. Mount/ unmount lifecycle is handled inside the hook — title clears on navigation away. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed focus, ISO dates
LeafletEditor:
- /create now passes `onImageUpload={(file) => uploadBlob(file)}`
so the Insert image button renders and uploads work. `did` alone
wasn't enough — `canUploadImages` requires both props.
- Toolbar buttons for link / image / embed no longer call
`editor.commands.focus()` after the click. The post-click focus
was racing the dialog's autofocus (the native showModal +
requestAnimationFrame targets the URL input, then editor.focus
yanks focus back to the surface) which is why opening the link
and embed dialogs felt broken. New `keepFocusOff` flag on the
internal `btn` helper turns the focus call off for the three
dialog-opening / file-picker buttons; the mark + heading
toggles keep the focus call so the caret returns to where the
user expects after a toggle.
Date format → ISO 8601:
- `formatShortDate` now emits `YYYY-MM-DD` directly (hand-built
from UTC parts so locale doesn't flip en-US output to
MM/DD/YYYY).
- `formatMonthYear` returns `YYYY-MM`.
- Both are unambiguous internationally and sort as plain strings.
Cascades through every surface that uses the helpers — cert
headline byline, feed time periods, endorsements list, context
updates, project detail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modal maxWidth and mapHeight are now derived from the live viewport instead of fixed values. Width caps at 1100 (sharing the hero width with the view-location modal) but clamps to viewport - 40 so there's always a 20px gutter on each side; a 320 floor keeps a usable form on ultra-narrow phones (content scrolls horizontally inside the dialog in that edge case). Map height = viewport - 40 - 280px (the non-map chrome the dialog also carries) capped at 720 and floored at 220 so the map doesn't shrink to a sliver on short windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rm nesting
LeafletEditor on /create sits inside the page's publish <form>.
The LinkDialog and EmbedDialog each wrap their submit in their
own <form onSubmit={...}>, which puts an inner form inside the
outer form — invalid HTML that browsers handle inconsistently.
The most visible symptom: the inner form's submit handler doesn't
fire reliably, so clicking "Add link" or "Embed" in those dialogs
did nothing.
createPortal each dialog into document.body so they live as
siblings of the page root rather than children of the publish
form. The image button's native file picker isn't affected — it
doesn't render a form — which matches the user's observation that
images work but link + video don't.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swaps the two <input type="date"> fields for <input type="text">
with `pattern="\d{4}-\d{2}-\d{2}"` and a "YYYY-MM-DD"
placeholder. The native date picker's visual format is
locale-dependent (MM/DD/YYYY in en-US, dd/mm/yyyy in en-GB, etc.)
which contradicted the rest of the app's ISO formatting. The
submit handler already consumes YYYY-MM-DD strings, so no other
changes were needed.
`inputMode="numeric"` keeps the mobile keyboard sensible;
`maxLength={10}` caps at the ISO length; aria-label spells out
the expected format for screen readers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the previous two-input (Search / Name) split with a single field that switches between "search" and "edit" modes: - Search mode (default, also after the user clears the field): typing fires the Nominatim typeahead. Suggestions dropdown appears beneath. Picking a suggestion (or clicking the map) flips into edit mode. - Edit mode: typing renames the saved value without re-firing a search. Placeholder shifts to "Rename to something more specific". No dropdown. The placeholder explanation above the field calls out the rename affordance directly so first-time authors know they can turn "123 Main St, San Francisco, CA, United States" into "Main office". Emptying the field flips back to search so the user can find a different place without an explicit "search again" button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Hint above the field drops the "(e.g. Main office instead of the
full address)" parenthetical. The shorter copy stays clear about
the rename affordance without padding the dialog.
- New `<Search>` icon button anchored to the right of the input in
edit mode. Clicking flips the field back to search mode using
the current value as the query — no need to clear the rename
first. `onMouseDown` (not onClick) so the input's focus-blur
doesn't close the suggestions before the handler fires.
- Enter now swallows its default (the dialog is inside the
publish form on /create; an unprevented Enter would submit the
cert mid-edit). Behaviour:
- search mode with open dropdown: pick the highlighted /
first suggestion (same as before).
- search mode with no dropdown / edit mode: re-enter search
using the current value, mirroring the icon button.
- New CSS rule grows the input's right padding via
`:has(.create-cert__loc-search-again)` so the typed value never
overlaps the icon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reverts the previous switch to <input type="text" pattern="..."> — that dropped the calendar picker UI users expect on date fields. The native <input type="date"> .value is ALWAYS YYYY-MM-DD regardless of the locale-dependent visual format in the field (en-US: MM/DD/YYYY, en-GB: DD/MM/YYYY, etc.), so the submit handler still consumes ISO 8601 strings either way. Only the displayed text formatting is locale-controlled and can't be overridden without giving up the picker. The cascade-everywhere ISO formatting (formatShortDate / formatMonthYear emit YYYY-MM-DD / YYYY-MM) is untouched — those control how dates are RENDERED in the read-only cert detail surfaces, where there's no input element involved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a single-icon create button to the right cluster of the desktop top-bar (left of the global search field, before Apps and Settings). Clicking opens a portalled dropdown with three shortcuts: - New cert → /create - New project → /project/new - New group → /groups/create The trigger and panel follow the existing account-switcher pattern verbatim — anchor recomputed on resize/scroll, close on outside click, Escape, and route change. Trigger renders only when the user is authenticated (the three target routes all require auth). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-requested placement. The button + portal panel logic is unchanged; only the JSX order shifts so the button sits between the search field and the Apps icon. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…utor field Groups can now publish certs from the same /create form their personal flow uses. Previously the page hard-blocked when `activeOrg` was set with a "Switch to your personal account" message — the underlying reason was that the xrpc proxy validates `repo === session DID`, so writes to a group's repo had to go through a different path. - `/api/groups/[groupDid]/activity` PUT route now accepts an optional rkey. rkey present → putRecord (existing update path). rkey absent → createRecord on the group's repo via `app.certified.group.repo.createRecord`. Mirrors the pattern the sibling location route already uses. - /create's submit picks between the xrpc proxy and the group BFF based on `activeOrg`. The image-blob upload, the LocationPickerDialog's putLocationRecord, and the LeafletEditor image upload all thread the same target DID so every blob / location / cert lands on the right repo. - The "Switch to your personal account" early-return is removed. Also extracted ContributorIdentityField + its helpers (`normalizeIdentity`, `isContributorIdentityAcceptable`, `isContributorWeightAcceptable`) into `src/components/create/contributor-identity-field.tsx` so the upcoming /project/new form can share them. /create now imports from that module. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the "Project editor — coming soon" EmptyState at
/project/new with a working form that mirrors /create's
structure and reuses its building blocks:
Layout (parallels the project-detail overview):
- Full-width banner image at the top (16:9 narrow → 21:9 wide
via the existing `.project-detail__hero` rules), with the
same `ImageEditOverlay` (with-remove variant) the cert image
slot uses.
- Title input (in `.cert-detail__headline` shell) with a
min/max grapheme counter under it.
- Short description textarea with the same counter pattern.
- `.project-detail__meta` strip carrying Time period (two date
inputs), Location (single string — the project lexicon stores
it as a plain string, not a strongRef array), and the long
description (LeafletEditor) on a full-width row.
- Contributors section reusing the cert-create row layout:
identity (typeahead) → role → weight → remove. Validation,
duplicate detection, "Add me" shortcut, and the per-row
inline error variants all behave identically.
Form gates (mirroring /create):
- Title min 5 / max 800, short description min 100 / max 300
(lexicon caps), all contributor identities must be a DID or
handle, weights numeric, no duplicate contributors.
- canSubmit + handleSubmit both enforce; the publish button
stays disabled until everything passes.
Wire format:
- POST `com.atproto.repo.createRecord` to the user's own repo
with `{ $type: "org.hypercerts.collection", type: "project",
title, shortDescription, createdAt, description?, banner?,
startDate?, endDate?, location?, contributors?, items: [] }`.
Empty `items` lets the project detail page's cert picker take
over post-create.
- Group-owned project creation is deferred (the existing
group-project BFF route is update-only); the form currently
writes to the signed-in user's repo when an org is active.
- After publish, redirect to `/project/<did>/<rkey>`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- The form was sitting in the `.page-layout` 296+1fr grid, which
reserved an empty 296px aside column and stretched the content
to the page-layout's main track. Switched to the same wrapper
the project detail page uses — outer `.project-detail-page` +
`.project-detail--wide` (the latter triggers `.app-shell__
content` to widen to 1100px via the existing `:has()` rule) and
inner `.project-detail` (which caps at 960px on desktop, 720px
narrow, centered with 16px gutters). Form content width now
matches the project detail overview exactly.
- DesktopTopBar's project-detail tabs row (Description / Certs /
Updates) was firing on `/project/new` because the predicate was
a `pathname.startsWith("/project/")` check. Excluded
`/project/new` so the create form doesn't carry the tabs row of
an existing project record.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… offset Two related fixes for the broken settings layout: 1. OrgSettings (the panel shown when the viewer is acting as a group) wrapped its content in `<div className="sx__layout">`, but `.sx__layout` was removed when the personal settings panel migrated to the shared `.page-layout` grid. Without a grid parent the aside (which still carries `position: sticky` + full width) ended up block-level and visually overlaid the panel during scroll. Switch the wrapper to the same `.page-layout` / `.page-layout__main` chrome the personal panel uses, so both settings entry points share one layout. Also drop `sx--wide` — it had no CSS, only meaning back when the `:has(.sx)` width override didn't exist. 2. Sticky offset on `.sx__menu` was `row1 + row2 + 16px = 112px`, but the settings page renders row 1 only. The 44px row-2 padding left a visible gap between the navbar's bottom border and the pinned nav. Switch to `--top-bar-total + 16px`, which resolves to row 1 only on pages without a tabs row and row 1 + row 2 on pages with one. Same change applied to the rail's `max-height` cap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…picker; align icons Collection-lexicon corrections: - Drops contributors[] and time-period (startDate/endDate) — those fields exist on `org.hypercerts.claim.activity`, NOT on `org.hypercerts.collection`. The earlier draft had carried them over from the cert form. - title.maxGraphemes is 80 (not 800); the title counter now caps at 80. - shortDescription is optional in the lexicon — dropped the min, kept the maxGraphemes 300 ceiling. - Required fields are now just title + createdAt, matching the spec. Cert picker added directly on the create form: - "Add cert" button reveals the shared `CertSearch` typeahead (same component the project detail edit mode uses). Selecting a cert pushes a strongRef onto a local `items` state; the row is rendered above the picker with a remove button. On submit the array is attached as `items[]` on the record. - The picker prioritises the user's own certs and excludes URIs already added so duplicates can't slip through. Icons + headings: - `.project-detail__meta-label` now `display: inline-flex` with a 5px gap so the small leading icons sit on the same baseline as the uppercase label text, matching the cert-detail meta-label rhythm. Location is still deferred to a follow-up — the cert-create LocationPickerDialog needs to be extracted into a shared component before /project/new can render a single-location variant of it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… single location picker - Moves the LocationPickerDialog (and its `AddedLocation` shape) out of `src/app/create/page.tsx` and into `src/components/create/location-picker-dialog.tsx` as a default-exported component. The dialog itself is unchanged — same two tabs (My locations / New), same Nominatim flow, same return shape — only its home moved. - /create imports from the new module; the inline definition is gone, freeing about 670 lines from the cert form. - /project/new now renders a "Location" section between Description and Certs. The "Add location" button opens the same dialog; the host stores ONE picked entry (the collection lexicon carries a single `location` strongRef, not an array). The chip below the button shows the resolved name with a remove button; "Add location" flips to "Change location" once one is set. - Drops the "— optional" hint from the project's short-description placeholder; the counter without a min already signals it's optional. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Once listRecords confirms zero published locations, drop the tab strip entirely and flip the dialog straight into the New flow. Single-option tab bars read as visual noise — first-time users land on the actually-useful surface instead of a tab that points at an empty state. The tab stays visible during the initial fetch so the dialog doesn't flicker between layouts on first open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Above the CertSearch typeahead, /project/new now renders a click-to-add checklist of the author's own certs. On mount it listRecords org.hypercerts.claim.activity against the active repo (the user's own DID or the group they've switched into), sorts newest-first, and renders each row as a checkbox label. Toggling a row adds/removes the strongRef from the project's items[] array. Added rows fade to 0.6 opacity so the eye lands on the still-unattached certs. The CertSearch typeahead is still there below — relabelled "Search for any cert" — for finding certs that aren't the author's own. Both surfaces feed the same items[] state; the existing exclude-by-URI keeps duplicates from sneaking in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a override that caps the shell at 1008px on desktop instead of the cert detail page's 1280px. 1008 = 960 (matching .project-detail's single-pane width) + 48 (.page-layout's 24px horizontal padding each side). The 296px aside stays as-is; the only column that narrows is the flex main pane, from ~904px down to ~632px. Cert detail (view + inline edit) is untouched because it doesn't carry .create-cert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Interactive mockups and a single decision document for how a person acts for the groups they own/admin: the seven-pattern design space, the three candidates (Options 1/2 act-as refinements, Option 3 GitHub model), the "an org is a full account, not a namespace" insight, and the recommendation (act-as framed as delegation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lass actors
A person who owns or admins a group can now operate that group as a
first-class atproto actor: no separate login, and no sticky mode that hides
who is acting. Identity is always legible and writes are attributed correctly.
Backend (group BFF routes):
- POST/DELETE /api/groups/[did]/endorse — create/delete app.certified.badge.award
on the group repo.
- POST /api/groups/[did]/endorsement-definition — mint the group's endorsement
definition on its repo.
- DELETE added to /api/groups/[did]/follow (group unfollow; create already existed).
Lib (src/lib/atproto):
- createEndorsementAward / deleteEndorsementAward / deleteFollow gain an optional
{ targetDid } and route through the group BFF when delegating;
ensureGroupEndorsementDefinition resolves/creates the group's definition via a
federated read. All personal paths are byte-for-byte unchanged.
Frontend:
- ActingAsBar: persistent delegation indicator on every screen,
"Operating <Group> as @<you> (<role>)" with an inline Switch to personal.
- Acting-as persistence moved from localStorage to sessionStorage (session-scoped,
so a delegation can't silently outlive the session).
- Profile Follow / Endorse / unfollow (sidebar, followers grid, and the
Endorsements tab give + revoke) act AS the group when delegating; the endorse
reason modal names the endorser, the operator, and the subject. The earlier
personal-only guard is replaced by real group routing.
Out of scope (follow-ups): respond-as-group to received endorsements
(badge.response), the personal /endorsements page + endorsement lists stay
personal, personal-only nav-gating unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ceive
Completes the org endorse-actor story: an admin acting as a group can now
accept / reject (and reset) endorsements the GROUP receives, writing the
app.certified.badge.response to the group's repo instead of the personal one.
- New POST/DELETE /api/groups/[did]/response (group badge.response), mirroring
the endorse route.
- createResponse / deleteResponse / deleteAllResponsesForAward gain an optional
{ targetDid } and route through the group BFF when delegating; personal paths
unchanged.
- useOwnResponseStates takes an optional responder DID so the Received tab
reflects the GROUP's accept/reject state when acting as it.
- ResponseButtons / ResponseMenu accept a targetDid; the profile Endorsements
Received tab passes it when acting as the group. The give and respond owner-
side gates are unified under a single canManage = owner || acting-as-this-group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(org): act-as delegation — orgs can endorse and follow as first-class actors
On your own profile's Groups tab, each group row gains an "Operate as" button (delegate into the group), deliberately separate from the row link which goes to the group's profile. The active group shows a disabled "Operating" state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The endorse delegation modal said "acting as an admin" regardless of the operator's actual role. operatorRole is now threaded from activeOrg.role (owner/admin/member). The ActingAsBar already used the variable role; the modal was the only hard-coded instance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
While delegated you could still give/revoke/accept/reject endorsements on your PERSONAL account, which writes to your personal repo while the chrome says you're the org — confusing and wrong. Personal management now requires !activeOrg; group management requires acting AS that group. Audited and fixed every instance of the pattern: - profile Endorsements tab: canManage = (owner && !activeOrg) || acting-as-this-group; personal endorsement lists hidden while delegated - /notifications: accept/reject controls hidden while delegated - /endorsements page: redirects to /home while delegated Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typed-list paste flow required an at:// URI and rejected a bare DID
("Not an at:// URI"). For an accounts list you can now paste just
did:plc:… (with or without at://) and it expands to the conventional
at://<did>/app.certified.actor.profile/self item. Help text + placeholder
updated to show the DID form. Cert / project lists are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(org): delegation follow-ups — operate-as button, real role, no personal actions while delegated
feat(lists): accept a bare DID when adding accounts to a list
…items
Several highlighted ("active") items rendered white text on a near-white
fill in dark mode. Two root causes, both fixed by using theme-aware tokens:
1. background: var(--fg-primary) + a FIXED white text (#ffffff, or
var(--bg-primary, #fff) where --bg-primary is an undefined token that
silently resolves to #fff). --fg-primary flips to near-white in dark
mode, so the fill and the text were both light. Fixed: text uses
var(--bg-canvas), which inverts with the fill (light in light mode,
dark in dark mode), matching the existing .onboarding-modal__step--current.
- .explore__filter--active, .explore__view-btn--active (explore.css)
- .sx-menu__item--active + its icon (settings-page.css)
- workspace active items x5 (workspace.css)
2. background: rgba(255,255,255,.92) (a fixed near-white image-overlay pill)
+ color: var(--fg-primary), which flips near-white in dark mode. Fixed:
text uses the invariant var(--color-primary) since the surface is invariant.
- .image-edit-overlay__btn--ghost (components.css)
- .profile-banner-upload__btn--ghost (profile-inline-edit.css)
Verified via computed styles in dark mode: each is now near-black text on
its light fill. Build compiles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(dark-mode): white-on-white text on highlighted/active items
Removes the 'Record' (raw schema field/value table) tab from the cert detail page: - drop it from CERT_DETAIL_TABS (the tab button), the only definition - remove 'record' from activity-detail's activeTab union + ?tab parsing (a stale ?tab=record link now falls back to Overview) and delete the record panel branch - remove the now-orphaned .cert-detail__record* CSS Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chore(cert): remove the Record tab from the cert overview
The /welcome footer was wrapped in .landing-section__inner (max-width 1536px, centered) with an extra 32px on .welcome-footer, so its off-white band and border-top stopped short of the viewport edges. Remove the cap and wrapper padding so the footer spans the full viewport like SiteFooter does on every other page; SiteFooter keeps its own 32px content inset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Renames the product noun cert/certs/certificate -> activity/activities in user-visible strings only: page titles, section headings, tab labels, empty states, aria-labels, placeholders, toast/error copy, and metadata. Preserved exactly: the "Certified" brand, app.certified.* / org.hypercerts.* lexicon NSIDs, certs.social, CGS; and all code identifiers, type/enum/wire values (kind, list:certs, === "certs"), DOM ids, className strings, and filenames. URL/query values are handled in the next commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…idth fix(welcome): full-bleed footer
Renames the user-visible URL value "certs" -> "activities" across the three ?tab/?kind surfaces, with legacy-alias parsing so old links still resolve: - explore ?kind=certs -> ?kind=activities (ExploreKind, parseKind, SUB_OPTIONS key, and the desktop tab strip's active-kind read) - profile ?tab=certs -> ?tab=activities (TabKey, parser, tabpanel/tab DOM ids) - project ?tab=certs -> ?tab=activities (its self-contained tab union + parser) Plus every link that builds those URLs (home, profile-overview, workspace, project). Kept (not URL values): record/wire discriminators (list:certs, cert.create), WorkspaceLexicon "certs", FeaturedKind/MA_EARTH_COLLECTIONS.certs (static internal lookups), the recently-viewed localStorage kind, and all identifiers, CSS classes, and filenames. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vity refactor(naming): rename cert → activity (display text + URL values)
Author bylines (/explore) and the activity-detail contributor list each resolved one DID per row via GET /api/resolve-did on mount. That route is capped at 60 requests/min per IP, so a page with many distinct authors plus a contributor-heavy activity blew the window and returned 429 — the "author/contributors not loaded" + "Failed to load resource: 429" report. Two amplifiers: the byline has no denormalized fallback (always resolves over the network), and the hooks deleted-but-never-recovered failed entries, so a 429'd avatar stayed broken until reload. Collapse N requests into one, mirroring how the home feed already avoids N lookups by denormalizing inline: - Extract the per-DID resolution out of the GET route into a shared resolve-core.ts (GET behaviour byte-identical; its 16 tests still pass). - Add POST /api/resolve-dids: resolves up to 50 identities (DID or handle) in one request with bounded server concurrency — one rate-limit hit per page instead of per row. - Add a DataLoader-style client coalescer (resolve-did-batch.ts): loads in a render pass batch into one POST; dedup + bounded cache; on 429 it degrades to a DID fallback and negative-caches with a TTL (no throw, no retry storm, self-heals on the next view). - Rewire useAuthorInfo / useContributorInfo / useAuthorNamesMap onto the coalescer; public APIs unchanged. A page needing K authors now issues ceil(K/50) requests, not K. Tests: coalescer (batch/dedup/429-degrade/TTL-requery/chunking) + batch route (keying/handle-resolution/cap/dedup/limiter). 533/533 pass; tsc clean; build compiles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round-1 implementation review (docs/resolve-did-batch/review-round-1.md): - Weight the batch route's rate limiter by identity count (cost = identities.length) against a 600-identity/min budget, instead of a flat 60-request budget that let one IP drive ~9k upstream fetches/min through the 50x fan-out. Adds a backward-compatible `cost` param to checkHttpRateLimit (incrby only when cost>1; TTL gate generalised to count===cost); every existing cost-1 caller is byte-identical. Side benefit: lifts anonymous users off the GET route's shared "anon" 60/min bucket that itself contributed to the 429s. - Coalescer: honor the 429 cooldown inside flush() (a flush armed before a concurrent chunk's 429 no longer fires early), and track negative-eviction timers in a Map so they're replaced on reschedule and cleared on reset (no timer leak / no fake-timer bleed across tests). - Tests: cooldown-defers-next-batch, mid-flush re-entrancy, incrby-weighted 429; batch mocks updated for incrby. Declined (rationale in review doc): useAuthorNamesMap self-heal (would cause a per-render setTick loop), dead error plumbing in useAuthorInfo (harmless defensive code in the public type), concurrent chunk flush (sequential is intentionally gentle on the limiter). 536/536 tests; tsc clean; build compiles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(resolve-did): batch author/contributor resolution to stop 429s
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.
Tip: disable this comment in your organization's Code Review settings.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…g with the redesign branch
`npm test` (vitest 4.1.6 -> rolldown 1.0.1) imports `styleText` from `node:util`, which only exists from Node 20.12.0. CI, .nvmrc, and the engines floor were pinned to 20.9.0, so `vitest run` crashed at startup with "does not provide an export named 'styleText'". Raise CI + .nvmrc to 20.19.0 (latest 20.x LTS) and the engines floor to >=20.12.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings
feat/positioning-redesignintostaging.Scale
What's included (high level)
X-RateLimit-Bypassheader on the/api/indexer+/api/notificationsproxies, test-file type-error fixes, and atypecheck:testCI step.Notes for review
🤖 Generated with Claude Code